@x12i/editor 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,28 +1,361 @@
1
1
  # @x12i/editor
2
2
 
3
- Document schema for the Unified UI Kit block editor.
3
+ An accessible block editor for the Unified UI Kit, built on [Tiptap](https://tiptap.dev) v3 (MIT).
4
4
 
5
- > **Status: foundation.** This package ships the versioned document JSON and the
6
- > provider contracts for collaboration and AI assistance. The editor, the engine
7
- > spike and the converters are not built yet.
5
+ The stored document is **JSON that the kit owns** (`EditorDocument`, versioned). Tiptap is only the
6
+ engine that edits it: the editor converts to and from Tiptap/ProseMirror JSON at its edges, so the
7
+ engine can be replaced without migrating saved documents.
8
8
 
9
- The stored document is JSON that the kit owns, so the engine behind the editor
10
- can change without migrating saved documents. It includes the kit blocks: a
11
- chart block holds a [`ChartSpec`](../charts), a diagram block holds a
12
- [`DiagramDocument`](../diagrams) and a data view block holds a
13
- [`SavedView`](../data).
9
+ - Headings, lists, task lists, tables, callouts, toggles, columns, code, equations, images, video, audio,
10
+ files and bookmarks, plus the kit blocks: **chart**, **diagram**, **data view** and **AI block**.
11
+ - Slash menu, markdown shortcuts, floating selection menu, toolbar, block menu, keyboard block moves.
12
+ - Read-only `EditorView` that renders on the server.
13
+ - Headless converters and exporters (HTML, Markdown, plain text, outline, diff, validation) that run anywhere.
14
+ - Provider contracts for AI assistance (with a ready streaming UI) and collaboration (contract only).
15
+ - Tokens-only styling (light, dark and high-contrast themes), RTL, i18n through a `messages` prop.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ pnpm add @x12i/editor @x12i/ui react react-dom
21
+ ```
22
+
23
+ `@x12i/ui` and React are peer dependencies. The editor imports `Button`, `IconButton`, `Badge`, `Stack`,
24
+ `Inline`, `Divider`, `Spinner` and `VisuallyHidden` from `@x12i/ui`. Wrap your app in the kit's
25
+ `ThemeProvider` (and optionally `DensityProvider`) and load the editor stylesheet once:
14
26
 
15
27
  ```ts
28
+ import "@x12i/editor/styles.css";
29
+ ```
30
+
31
+ ## Two entry points
32
+
33
+ | Import | Contents | Runs on the server |
34
+ | ---------------------------- | ------------------------------------------------------------------------------------------ | ------------------ |
35
+ | `@x12i/editor` | Document schema and types, converters, exporters, outline, diff, validation, token helpers | Yes. No React, DOM or Tiptap imports. |
36
+ | `@x12i/editor/react` | `Editor`, `EditorView`, `EditorToolbar`, AI menu and hook, message helpers | `EditorView` yes; `Editor` renders `EditorView` until it mounts. |
37
+ | `@x12i/editor/styles.css` | The one stylesheet | n/a |
38
+
39
+ `@x12i/editor/react` starts with `"use client"`, so it works from a Next.js server component tree as a
40
+ client boundary. The root entry has no directive and no side effects.
41
+
42
+ ### Server-side rendering
43
+
44
+ - `EditorView` is a pure render of the document. Use it in server components, static pages and email-like
45
+ output, and wherever people only read.
46
+ - `Editor` creates its Tiptap instance with `immediatelyRender: false`: on the server and during hydration it
47
+ renders the same markup as `EditorView`, then swaps in the live editor after mount. No hydration mismatch,
48
+ no layout jump.
49
+ - `documentToHtml(doc)` returns an HTML string with no React at all (all text is escaped, URLs are checked).
50
+
51
+ ## Quick start
52
+
53
+ ```tsx
54
+ "use client";
55
+ import { useState } from "react";
16
56
  import { createEmptyDocument, type EditorDocument } from "@x12i/editor";
57
+ import { Editor } from "@x12i/editor/react";
58
+ import "@x12i/editor/styles.css";
59
+
60
+ export function Notes() {
61
+ const [doc, setDoc] = useState<EditorDocument>(() => createEmptyDocument());
62
+ return <Editor value={doc} onChange={setDoc} debounce={300} aria-label="Meeting notes" autoFocus />;
63
+ }
64
+ ```
65
+
66
+ `value` makes the editor controlled; `defaultValue` makes it uncontrolled. External changes to `value` are
67
+ applied only when the new document differs from what the editor holds, and never fire `onChange`.
68
+
69
+ ### Read-only view
70
+
71
+ ```tsx
72
+ import { EditorView } from "@x12i/editor/react";
73
+
74
+ <EditorView value={doc} blockRenderers={blockRenderers} idPrefix="post-12-" />;
75
+ ```
17
76
 
18
- const doc: EditorDocument = createEmptyDocument();
77
+ ### Imperative API
78
+
79
+ ```tsx
80
+ import { useRef } from "react";
81
+ import type { EditorApi } from "@x12i/editor/react";
82
+
83
+ const api = useRef<EditorApi>(null);
84
+
85
+ <Editor ref={api} defaultValue={doc} />;
86
+
87
+ api.current?.insertBlock("callout", { variant: "warning" });
88
+ api.current?.exec("heading2");
89
+ const markdown = api.current?.getMarkdown();
90
+ ```
91
+
92
+ `onReady(api)` hands you the same object. `EditorApi`: `getDocument`, `setDocument`, `focus`, `insertBlock`,
93
+ `exec`, `getMarkdown`, `getOutline`, `getEngine` (the raw Tiptap editor) and `announce`.
94
+
95
+ ## Charts, diagrams and data views: `blockRenderers`
96
+
97
+ The editor **never imports** chart, diagram or data components. `@x12i/charts`, `@x12i/diagrams` and
98
+ `@x12i/data` are used for **types only**. Embedded blocks are drawn by renderers you inject, so an app pays
99
+ only for what it uses and the packages stay independent:
100
+
101
+ ```tsx
102
+ import type { BlockRenderers } from "@x12i/editor/react";
103
+ // Your app's own components: the editor does not care where they come from.
104
+ import { MyChart, MyDiagram, MyDataView } from "./blocks";
105
+
106
+ // Define at module level (or memoize): see "Renderer identity" below.
107
+ const blockRenderers: BlockRenderers = {
108
+ chart: ({ payload, title }) => <MyChart spec={payload} aria-label={title} />, // payload: ChartSpec
109
+ diagram: ({ payload }) => <MyDiagram engine={payload.engine} document={payload.diagram} source={payload.source} />,
110
+ "data-view": ({ payload }) => <MyDataView dataset={payload.dataset} view={payload.view} />, // payload.view: SavedView
111
+ };
19
112
  ```
20
113
 
21
- ## Planned engine
114
+ Each renderer receives `{ type, id, payload, title, editable, selected }`. Payload types are exported as
115
+ `EmbedPayloads`.
116
+
117
+ Without a renderer, a block shows a tidy default card: a titled group with a summary and a "Show data"
118
+ disclosure holding the JSON. That card is also what appears if a renderer throws (each renderer sits in an
119
+ error boundary).
120
+
121
+ ### Editing and inserting embedded blocks
122
+
123
+ ```tsx
124
+ <Editor
125
+ blockRenderers={blockRenderers}
126
+ onEditBlock={async (type, payload) => openChartDialog(type, payload) /* resolve the new payload, or null */}
127
+ onRequestBlock={async (type) => pickChart(type) /* resolve a payload, or null to cancel */}
128
+ />
129
+ ```
130
+
131
+ Selecting an embedded block shows a small "Block tools" toolbar (Edit, Move up, Move down, Remove). Pressing
132
+ Enter on a selected block calls `onEditBlock`. Choosing "Chart" in the slash menu calls `onRequestBlock`; if
133
+ it is missing, the block is created with an empty payload and the default card.
134
+
135
+ ### Renderer identity
136
+
137
+ Renderers are mounted as React components, so they may use hooks. Keep the functions stable (module level
138
+ or `useMemo`): a new function identity on each render makes React remount that block and discard its state.
139
+
140
+ ## Supported blocks and marks
141
+
142
+ | Document block (`type`) | Tiptap node | Notes |
143
+ | --------------------------- | ----------------------------------- | ---------------------------------------------------------------------- |
144
+ | `paragraph` | `paragraph` | |
145
+ | `heading` (level 1 to 4) | `heading` | |
146
+ | `quote` | `blockquote` | |
147
+ | `callout` (`variant`) | `callout` (custom) | info, success, warning, danger, neutral. `role="note"`. |
148
+ | `toggle` | `toggle` + `toggleSummary` (custom) | Disclosure button; Alt+Enter toggles. |
149
+ | `divider` | `horizontalRule` | |
150
+ | `bulleted-list` | `bulletList` / `listItem` | Nested. |
151
+ | `numbered-list` | `orderedList` / `listItem` | Nested. |
152
+ | `task-list` | `taskList` / `taskItem` | Nested. Each checkbox is labelled with its text. |
153
+ | `code` (`language`) | `codeBlock` (custom) | Language picker and copy button. No syntax highlighting. |
154
+ | `equation` | `equation` (custom) | LaTeX source shown as text. No math renderer bundled. |
155
+ | `table` | `table`, `tableRow`, `tableHeader`, `tableCell` | Row/column menu; header row and column; col/row spans kept. |
156
+ | `image` | `image` (custom view) | Alt text required (or "decorative"), caption, alignment, width. |
157
+ | `video`, `audio` | `media` (custom) | By URL; native controls. |
158
+ | `file` | `file` (custom) | Link card with name and size. |
159
+ | `bookmark` | `bookmark` (custom) | Link card. |
160
+ | `columns` | `columns` / `column` (custom) | |
161
+ | `page-break` | `pageBreak` (custom) | Breaks the page when printing. |
162
+ | `table-of-contents` | `tableOfContents` (custom) | Live list of headings. |
163
+ | `chart` | `chart` (atom, injected renderer) | Payload is a `ChartSpec`. |
164
+ | `diagram` | `diagram` (atom, injected renderer) | `engine` + `DiagramDocument` or `source`. |
165
+ | `data-view` | `dataView` (atom, injected renderer)| `dataset` + `SavedView`. |
166
+ | `ai` | `aiBlock` (atom, injected renderer) | `prompt` + optional `result` blocks. |
167
+ | any other `type` | `unknownBlock` | Kept untouched, reported as an `unknown-block` warning. |
168
+
169
+ | Inline | Tiptap | Notes |
170
+ | --------------------- | ------------------------------ | --------------------------------------------------------------------- |
171
+ | `bold`, `italic`, `strike`, `code` | `bold`, `italic`, `strike`, `code` | |
172
+ | `underline` | `underline` | |
173
+ | `link` (`href`) | `link` | URL allow-list: see Security. |
174
+ | `color` (`token`) | `textColor` (custom) | Stores a token path, never a color value. |
175
+ | `highlight` (`token`) | `highlight` | Stores a token path, never a color value. |
176
+ | `mention` | `mention` (custom) | Existing mentions render; there is no picker. |
177
+ | `emoji` (`shortcode`) | `emoji` (custom) | Stored as a shortcode, rendered as the character with an accessible name. |
178
+
179
+ ## Markdown shortcuts
180
+
181
+ Typed at the start of a block (then continue typing):
182
+
183
+ | Type | Result |
184
+ | ----------------------------- | ------------------------- |
185
+ | `# `, `## `, `### `, `#### ` | Heading 1 to 4 |
186
+ | `- `, `* `, `+ ` | Bulleted list |
187
+ | `1. ` | Numbered list |
188
+ | `[ ] `, `[x] ` | Task list (unchecked, checked) |
189
+ | `> ` | Quote |
190
+ | ```` ```ts ````, `~~~` | Code block (language optional) |
191
+ | `---` | Divider |
192
+
193
+ Inline: `**bold**`, `*italic*` or `_italic_`, `~~strike~~`, `` `code` ``, and `:rocket:` for emoji.
194
+ Pasted text keeps its formatting where Tiptap can parse it; URLs auto-link; unsafe URLs never become links.
195
+
196
+ ## Keyboard
197
+
198
+ Modifier is Ctrl on Windows and Linux, Cmd on macOS.
199
+
200
+ | Keys | Action |
201
+ | ------------------------------ | --------------------------------------------------------------------- |
202
+ | `/` at the start of a block or after a space | Open the slash menu |
203
+ | `ArrowDown` / `ArrowUp` (slash menu) | Move through blocks (wraps) |
204
+ | `Enter` or `Tab` (slash menu) | Insert the highlighted block |
205
+ | `Escape` (slash menu) | Close the menu and keep what you typed |
206
+ | `Mod+/` | Open the block menu (turn into, duplicate, delete, move, add below) |
207
+ | `Alt+Shift+Up` / `Down` | Move the current block up or down (announced to screen readers) |
208
+ | `Mod+K` | Add or edit a link |
209
+ | `Mod+B`, `Mod+I`, `Mod+U` | Bold, italic, underline |
210
+ | `Mod+Shift+S`, `Mod+E` | Strikethrough, inline code |
211
+ | `Mod+Shift+7` / `8` / `9` | Numbered, bulleted, task list |
212
+ | `Mod+Shift+B` | Quote |
213
+ | `Mod+Alt+C` | Toggle code block |
214
+ | `Mod+Enter` (code block) | Leave the code block |
215
+ | `Tab` / `Shift+Tab` in a list | Indent / outdent the item when that is possible; otherwise focus moves on. |
216
+ | `Tab` / `Shift+Tab` in a table | Next / previous cell. `Tab` in the last cell adds a row; `Shift+Tab` in the first cell moves focus out. |
217
+ | `Alt+Enter` (in a toggle) | Expand or collapse |
218
+ | `Enter` (embedded block selected) | Edit the block (`onEditBlock`) |
219
+ | `Mod+Z`, `Mod+Shift+Z` | Undo, redo |
220
+ | `Tab` to the toolbar, then `Left` / `Right`, `Home`, `End` | Toolbar is one tab stop with roving focus |
221
+ | `Escape` (menus, dialogs, bubble menu, AI menu) | Close and return to the text |
222
+
223
+ ## Accessibility
224
+
225
+ The editor is built to the kit's accessibility rules (spec section 13) and tested with
226
+ [axe](https://github.com/dequelabs/axe-core) in every tested state: editor with all blocks, toolbar, open
227
+ menus, slash listbox, dialogs, embedded blocks, and the read-only view.
228
+
229
+ - The editable area is `role="textbox"` with `aria-multiline="true"` and an accessible name
230
+ (`aria-label` / `aria-labelledby`, or the default from `messages`). The read-only editor is `role="document"`.
231
+ - Not a keyboard trap in text: `Tab` moves focus out of the editor (it is only used to indent list items, move
232
+ between table cells and confirm a slash menu choice), and `Escape` always closes the open menu. In a table,
233
+ `Tab` in the last cell adds a row (the Tiptap default); leave a table with `Shift+Tab` from its first cell or
234
+ with the arrow keys, then `Tab` from the text after it.
235
+ - The toolbar is a `role="toolbar"` with a roving tabindex (one tab stop, arrow keys inside). Toggle buttons
236
+ expose `aria-pressed`; disabled states are real `disabled`.
237
+ - The slash menu is a `listbox`: the editor carries `aria-controls` and `aria-activedescendant` while it is
238
+ open, the options carry `aria-selected`, and result counts are announced through a polite live region.
239
+ - Block moves, formatting changes, links, images, copy and AI results are announced in a status region.
240
+ - Dialogs (link, image) trap focus, close on `Escape`, return focus to where they started, and validate with
241
+ `aria-invalid` + `aria-describedby` error text. Images require alternative text, or an explicit "decorative".
242
+ - Focus rings use the kit's `--kit-color-border-focus` and `--kit-border-width-focus` tokens and are never
243
+ removed. Windows forced-colors and `prefers-reduced-motion` are handled in the stylesheet.
244
+ - Color is never the only signal: callouts have a variant label, task state is a checkbox, and the text colors
245
+ and highlights on the allow-lists are chosen from the kit's contrast-checked foreground/background tokens.
246
+ - RTL: only logical CSS properties; block elements use `dir="auto"` so right-to-left text mirrors lists, quotes
247
+ and tables. Code and equations are always `dir="ltr"`.
248
+
249
+ ## Theming
250
+
251
+ All styling uses `--kit-*` design tokens, nothing is hard-coded. Every selector in `styles.css` is wrapped in
252
+ `:where()` so it has zero specificity and is easy to override. Hooks for your own CSS:
253
+ `[data-kit="editor"]`, `editor-toolbar`, `editor-content`, `editor-callout` (`data-variant`), `editor-toggle`
254
+ (`data-open`), `editor-task-item` (`data-checked`), `editor-embed-block` (`data-embed`, `data-selected`),
255
+ `editor-embed-card`, `editor-slash`, `editor-slash-option`, `editor-bubble`, `editor-dialog-form`, and so on. Dark mode and high contrast follow `ThemeProvider`. The editor
256
+ defines only two private custom properties (`--kit-editor-x`, `--kit-editor-y`) for menu positioning.
257
+
258
+ ## Internationalization
259
+
260
+ Every string comes from `messages`. Pass a partial override; missing keys fall back to English:
261
+
262
+ ```tsx
263
+ import { Editor, defaultMessages, type DeepPartial, type Messages } from "@x12i/editor/react";
264
+
265
+ const de: DeepPartial<Messages> = {
266
+ placeholder: "Schreibe etwas oder drücke / für Blöcke",
267
+ toolbar: { bold: "Fett", italic: "Kursiv" },
268
+ blocks: { heading1: { name: "Überschrift 1" } },
269
+ };
270
+
271
+ <Editor messages={de} locale="de" />;
272
+ ```
273
+
274
+ Templates use ICU-style placeholders and `{count, plural, one {...} other {...}}` (via `Intl.PluralRules`
275
+ with `locale`). Slash menu search ignores case and diacritics and matches names, descriptions and keywords in
276
+ the active language. Helpers: `defaultMessages`, `mergeMessages`, `createTranslator`, `formatMessage`,
277
+ `useMessages`, `useTranslator`.
278
+
279
+ ## AI assistance
280
+
281
+ The kit calls no model. Provide an `AiProvider` (from the root entry) that streams text:
282
+
283
+ ```tsx
284
+ import type { AiProvider } from "@x12i/editor";
285
+
286
+ const aiProvider: AiProvider = {
287
+ async *run({ command, selection, tone, signal }) {
288
+ const response = await fetch("/api/ai", { method: "POST", body: JSON.stringify({ command, selection, tone }), signal });
289
+ if (!response.ok || !response.body) throw new Error(`Request failed (${response.status})`);
290
+ const decoder = new TextDecoder();
291
+ for await (const chunk of response.body) yield decoder.decode(chunk, { stream: true });
292
+ },
293
+ };
294
+
295
+ <Editor aiProvider={aiProvider} />;
296
+ ```
297
+
298
+ With a provider, the toolbar and the selection menu show an "AI assistant" menu (improve, shorten, continue,
299
+ fix spelling and grammar, ask with an instruction). The result streams into a **preview**; nothing changes the
300
+ document until the person chooses Accept, and Stop or Reject discards it. Build your own surface with
301
+ `useAiAssist({ provider, editor })` (status, preview, `run`, `accept`, `reject`, `cancel`) or `AiAssistMenu`.
302
+
303
+ ## Headless API (root entry)
304
+
305
+ ```ts
306
+ import {
307
+ documentToTiptap, tiptapToDocument, // convert to and from the engine's JSON
308
+ documentToHtml, documentToMarkdown, documentToPlainText,
309
+ markdownToDocument, // import (GFM subset)
310
+ getOutline, buildOutlineTree, // headings, ids, anchors
311
+ diffDocuments, // which blocks were added, removed, changed
312
+ validateDocument, sanitizeDocument, isEditorDocument,
313
+ isSafeUrl, sanitizeUrl, sanitizeMediaUrl,
314
+ textColorToCss, highlightToCss, TEXT_COLOR_TOKENS, HIGHLIGHT_TOKENS,
315
+ } from "@x12i/editor";
316
+ ```
317
+
318
+ All pure functions with typed options and warnings (`ConversionWarning`), safe to use in server code, workers
319
+ and tests. `tiptapToDocument(documentToTiptap(doc))` returns an equal document for every block and mark.
320
+
321
+ ## Security
322
+
323
+ - **URLs.** Links allow `http`, `https`, `mailto`, `tel` and relative paths. `javascript:`, `data:`, `vbscript:`
324
+ and friends are rejected in the editor, the converters, the view and the exporters. Only the `src` of
325
+ `image`, `video` and `audio` blocks additionally accepts `data:image/*` (so pasted or inline images work);
326
+ links, files and bookmarks never accept `data:`. Whitespace, control and invisible characters are removed
327
+ before the scheme is checked, so `java<TAB>script:` is rejected too. Links get `rel="noopener noreferrer"`.
328
+ - **No raw styles.** Text color and highlight store a token path. Only tokens on the allow-lists in the
329
+ package render, as `var(--kit-...)`. A stored document cannot inject CSS.
330
+ - **No raw HTML.** The React components never use `dangerouslySetInnerHTML`. `documentToHtml` escapes all text
331
+ and attributes and emits no inline styles or event handlers, so its output is safe to inject.
332
+ - **Embedded renderers** are your code; the editor passes them only typed payloads, and catches their errors.
333
+ - **Unknown data** (a newer block type, an unknown mark) is preserved and reported, never executed.
334
+ - `sanitizeDocument` and `validateDocument` are there for documents from untrusted sources.
335
+
336
+ ## Limitations
337
+
338
+ This is a complete editor, but not every feature of a large product. Honest list:
339
+
340
+ - **Collaboration is a contract only.** `CollaborationProvider` is typed; there is no Yjs binding, presence or
341
+ remote cursors in this version.
342
+ - **No drag handle.** Blocks move by keyboard (Alt+Shift+Up/Down), the block menu and the embed toolbar.
343
+ - **No syntax highlighting** in code blocks (language is stored and shown, not colored).
344
+ - **No mention picker.** Existing mentions render and round-trip; typing `@` does nothing yet.
345
+ - **No math renderer.** Equations show their LaTeX source in a monospace block.
346
+ - **Images by URL only.** No upload, drag-and-drop or paste-to-upload; `data:image` URLs are accepted.
347
+ - **Tables:** no column resizing. Cell content is inline only in the stored format. Merged cells are
348
+ preserved (`colSpan`, `rowSpan`).
349
+ - **Toggles open expanded** when a document loads; their open state is not stored.
350
+ - **Slash menu queries cannot contain spaces:** typing a space closes the menu.
351
+ - **Markdown export is lossy** for blocks Markdown cannot express (callout, toggle, columns, embeds are written
352
+ as readable fallbacks), and import follows GFM, where the first table row is the header.
353
+ - `trailingNode` is disabled, so a document may end with a non-paragraph block such as a table or a divider.
354
+ Add a line with "Add paragraph below" in the block menu (`Mod+/`).
355
+ - Extra `extensions` are read once, when the editor mounts.
356
+ - Code block, `Document`, toggle and callout nodes are written in this package because Tiptap's code-block
357
+ package is not a direct dependency under strict pnpm resolution.
22
358
 
23
- Decided by a scored spike: Tiptap core on ProseMirror (MIT) is the default if
24
- scores tie, with Plate and Lexical as alternatives. Collaboration will use Yjs
25
- behind `CollaborationProvider`, with Hocuspocus (MIT) as the reference server.
359
+ ## Licensing
26
360
 
27
- Not allowed: BlockNote XL packages (GPL-3.0 or commercial) and Tiptap Pro
28
- extensions or cloud features that need a subscription.
361
+ MIT. Only MIT-licensed Tiptap packages are used; no Tiptap Pro, cloud or paid extensions.